ListView 踩过的坑

ListView 组件在 react-native 开发中频繁使用到。所以在实际开发时,在实现多选操作时碰到一个坑,虽然使用很奇葩的手段解决了,但并没有从根本上解决遇到的问题。经过别人指点后,终于了解问题产生的原因,记录下这个算得上是基础知识的坑。

其实只有一个问题,但是这里也将 ListView 常用到的功能点列出来。

渲染数据

'use strict';
import React, {Component} from 'react';
import {
  StyleSheet,
  View,
  ListView,
  Text,
  Image,
  // width and height of screen
  Dimensions
} from 'react-native';
// 获取屏幕宽高
let {height, width} = Dimensions.get('window');
// 从豆瓣api一次获取多少条数据
let pageSize = 5;

export default class ListViewDemo extends Component {
  constructor(props) {
    super(props);
    // 初始化 state
    this.state = {
      loading: true,
      data: [],
      dataSource: new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2 })
    };
  }
  // 
  componentDidMount() {
    // 从豆瓣api获取数据,搜索react相关的书籍
    fetch('https://api.douban.com/v2/book/search?q=react&count='+pageSize)
      .then(res=> {
        if(res.status === 200) {
          // 把得到的字符串转换为对象
          let data = JSON.parse(res._bodyInit).books;
          this.setState({
            loading: false,
            data: data,
            dataSource: this.state.dataSource.cloneWithRows(data)
          });
        }
      })
      .catch(err=> {
        alert(JSON.stringify(err));
      })
  }
  // 单列样式
  _renderRow(row, sectionId, rowId) {
    return (
      <View 
        style = {styles.item}
      >
        <Image
          source = {{uri: row.image}}
          style = {{width: 85, height: 120, marginRight: 20}}
          resizeMode = 'stretch'
        />
        <View style = {{flexDirection: 'column', width: width-150}}>
          <Text style = {{fontSize: 16}}>{row.title}</Text>
          <Text
            numberOfLines = {3}
            style = {{color: '#ccc'}}
          >{row.summary}</Text>
        </View>
      </View>
    )
  }
  render() {
    if(this.state.loading) {
      // 如果正在加载数据,就显示 loading...
      return (
        <View style = {styles.empty}>
          <Text>loading...</Text>
        </View>
      )
    }
    return (
      <View style = {styles.container}>
        <ListView
          dataSource = {this.state.dataSource}
          renderRow = {this._renderRow.bind(this)}
     // 由于屏幕高度一次只够显示4条,所以这里设置为4可以稍微提高性能?
          initialListSize = {4}
          // 隐藏滚动条
          showsVerticalScrollIndicator = {false}
        />
      </View>
    )
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#eee'
  },
  empty: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
  },
  item: {
    paddingVertical: 10,
    paddingHorizontal: 20,
    borderBottomWidth: 1,
    borderStyle: 'solid',
    borderColor: '#ccc',
    flexDirection: 'row'
  }
});

有一点需要注意,由于 _renderRow 是我们自己定义的方法,所以在这个函数里面出现的 this 值需要手动绑定,让 _renderRow 里面的 this 指向我们需要的this。而在 render 函数里面, this 值就是我们需要的,所以this._renderRow.bind(this)就可以了。

如果某个需要绑定 this 值的方法经常用到,可以在构造函数内绑定,这样就不需要每次调用的时候都 .bind(this) 了。

constructor(props){
    //
    this._renderRow = this._renderRow.bind(this);
}

增加数据

可以用 onEndReached 属性,值是一个函数,当触底时会执行该函数,在这个函数内获取数据。也可以用
renderFooter 在列表底部渲染一个按钮,点击才加载数据。

这里采用第二种:

// 渲染底部按钮的函数
_renderFooter() {
    if(this.state.loadingMore) {
      return (
        <View
          style = {{paddingVertical: 10, justifyContent: 'center', alignItems: 'center'}}
        >
          <Text>loading...</Text>
        </View>
      )
    }
    return (
      <TouchableOpacity
        onPress = {this.loadMore.bind(this)}
        style = {{paddingVertical: 10, justifyContent: 'center', alignItems: 'center'}}
      >
        <Text>click it load more</Text>
      </TouchableOpacity>
    )
  }

可以看到这里和之前的思路一样,如果正在加载数据,底部按钮就会显示 loading,加载成功后才显示可点击的按钮。

// 获取更多数据的函数
loadMore() {
  // 当点击按钮时,就把底部按钮设置为 loading 状态。
    this.setState({
      loadingMore: true
    });
  // 为了实现分页,**初始化 state 时加上一个属性 index,初始值为2**,因为加载更多的时候就是在加载第二页了
    let start = (this.state.index-1)*pageSize;
    fetch(`https://api.douban.com/v2/book/search?q=react&start=${start}&count=${pageSize}`)
      .then(res=> {
        if(res.status === 200) {
          // parse response
          let response = JSON.parse(res._bodyInit).books;
          if(response.length === 0) {
            alert('no data response');
            return;
          }else {
            let oldAry = [...this.state.data];
            let newAry = [...oldAry, ...response];
            this.setState({
              loading: false,
              loadingMore: false,
              data: newAry,
              dataSource: this.state.dataSource.cloneWithRows(newAry),
       index: this.state.index+1
            });
          }
        }
      })
      .catch(err=> {
        alert(JSON.stringify(err));
      })
  }
_renderRow(row, sectionId, rowId) {
    return (
      <View 
        style = {styles.item}
      >
        <Image
          source = {{uri: row.image}}
          style = {{width: 85, height: 120, marginRight: 20}}
          resizeMode = 'stretch'
        />
        <View style = {{flexDirection: 'column', width: width-150}}>
          <Text style = {{fontSize: 16}}>{row.title}</Text>
          <Text
            numberOfLines = {3}
            style = {{color: '#ccc'}}
          >{row.summary}</Text>
        </View>
      </View>
    )
  }

最后是给 ListView 组件加上 renderFooter 属性

<ListView
          dataSource = {this.state.dataSource}
          renderRow = {this._renderRow.bind(this)}
          initialListSize = {4}
          showsVerticalScrollIndicator = {false}
          renderFooter = {this._renderFooter.bind(this)}
        />

重点是在 loadMore 方法的这部分代码:

    let oldAry = [...this.state.data];
    let newAry = [...oldAry, ...response];

即新获取到的数据,如何放到已有的数组中,this.state.data 是保存这前五条数据的数组这个毫无疑问,接下来因为我们不能直接改变 state(需要使用setState),所以不能用 push、unshift,所以这里是先取到 this.state.data,然后使用 ... 展开,新获取到的数组也展开,一起放到数组中,赋给新变量。

删除数据

首先是用来删除的方法:

delete(row) {
    let oldAry = [...this.state.data];
    let index = oldAry.indexOf(row);
    oldAry.splice(index, 1);
    let newAry = oldAry;
    this.setState({
      data: newAry,
      dataSource: this.state.dataSource.cloneWithRows(newAry)
    });
  }

传入 row 作为参数,查询到长按的item在数组中的序列,然后删除,把删除后的数组赋给一个新变量。
可能

let newAry = [
    ...oldAry.slice(0, index),
    ...oldAry.slice(index+1)
];

这样会更好?暂时都可以,然后添加上长按事件,给每一行添加长按事件:

_renderRow(row) {
    return (
      <TouchableOpacity 
        style = {styles.item}
        onLongPress = {this.delete.bind(this, row)}
      >
        <Image
          source = {{uri: row.image}}
          style = {{width: 85, height: 120, marginRight: 20}}
          resizeMode = 'stretch'
        />
        <View style = {{flexDirection: 'column', width: width-150}}>
          <Text style = {{fontSize: 16}}>{row.title}</Text>
          <Text
            numberOfLines = {3}
            style = {{color: '#ccc'}}
          >{row.summary}</Text>
        </View>
      </TouchableOpacity>
    )
  }

就可以了,这里要注意的是,需要传入一个参数。

选中状态

上面的实现都还算顺利,在实现选中状态,也就是多选功能时踩到了坑。功能点是:一个列表支持多选,然后可以批量删除。先实现选中功能。

选中功能可以用背景色或者图标来区分选中与未选,有两种思路,

  • 一个全局数组,选中后将row放到这个数组中,判断row是否在这个数组中来表示是否选中。
  • 添加一个字段,这个字段标识是否被选中。

全局数组

先说明,这种方式行不通,或者说很难实现,下面是一步一步尝试到无法实现。
先给初始化 state 添加一个 selectedAry: [],用来保存选中的 row。然后是 choose 事件:

choose(row) {
    // 
    let oldSeletedAry = [...this.state.seletedAry];
    let index = oldSeletedAry.indexOf(row);
    let newSeletedAry = [];
    if(index > -1) {
      // is exist
      oldSeletedAry.splice(index, 1);
      newSeletedAry = oldSeletedAry;
    }else {
      newSeletedAry = [...oldSeletedAry, row];
    }
    this.setState({
      seletedAry: newSeletedAry
    });
  }

如果点击的这个row已经存在被选择数组中,就移除,思路和长按删除一样;否则就是添加到被选择数组中。
然后给每一行添加上点击事件,就在 onLongPress 下面加上

onPress = {this.choose.bind(this, row)}

然后找个地方放这个数组的长度,这样就能很直观看到选择的数量。
在 ListView 组件上方放一个 Text 组件。

<Text>{this.state.seletedAry.length+'/'+this.state.data.length}</Text>

看看效果,的确是能够正确显示出数量了,还需要给一个状态来区分出是否被选择。在每一行添加一个 Text 组件,如果没有被选择,就显示 NO,如果被选择了,就显示 YES。

// 先判断在不在
let index = this.state.seletedAry.indexOf(row);
// 然后是显示是否被选中,在之前的Text 组件下面再添加一个组件
// ...
<Text>{index > -1 ? 'YES' : 'NO'}</Text>

OK,页面能正确显示出 NO 了,点击一下,问题来了,被选择数组的长度+1,但是 NO 并没有变成 YES。原因在于,我们做的这些操作,都没有触发到 ListView 重新渲染,setState 的确会触发 render 函数,但是却不会触发 _renderRow 函数啊,所以这里没有变化。OK,问题既然找到了,解决办法就是在 choose 函数内修改 dataSource,以触发 _renderRow 。

choose(row) {
    // 
    let oldSeletedAry = [...this.state.seletedAry];
    let index = oldSeletedAry.indexOf(row);
    let newSeletedAry = [];
    if(index > -1) {
      // is exist
      oldSeletedAry.splice(index, 1);
      newSeletedAry = oldSeletedAry;
    }else {
      newSeletedAry = [...oldSeletedAry, row];
    }
    let oldAry = [...this.state.data];
    let newAry = oldAry;
    this.setState({
      seletedAry: newSeletedAry,
      data: newAry,
      dataSource: this.state.dataSource.cloneWithRows(newAry)
    });
  }

拿到旧数组,赋给新数组,这样OK吗,答案是NO。这个地方我的理解是,虽然有新数组了,但是数组里面的对象还是原来的对象,内存地址并没有变化。
举个例子

let ary = [{
  name: 'ltaoo'
}, {
  name: 'ltooo'
}];

let newAry = [...ary];

newAry[0].name = 'loooo';

console.log(ary);

虽然看起来是修改了 newAry 里面对象的值,实际上原数组 ary 的值也发生了改变。所以我们知道了 ListView 判断数组是否发生了改变,要看里面的元素是否发生了变化,而对象需要内存地址不同,才算发生了变化。

那这里如何解决呢?答案是把对象每个都拷贝:

let oldAry = [...this.state.data];
    let newAry = oldAry.map(item=> {
      return Object.assign({}, item);
    });

虽然的确是触发了 _renderRow 函数(在_renderRow开始alert可以判断是否触发),但是NO 还是 NO,因为 index 的确是 -1 ,经过拷贝后,每一次看到的数组,和上一次都是不同的,即使如果先拷贝,再放到 selectedAry 中去,只能实现只有一个是 YES,点击了另外的,另一个变为 YES,之前为 YES 的变为了 NO。可以仔细思考为什么,这里放出最后挣扎的代码:

choose(row) {
    let oldSeletedAry = [...this.state.seletedAry];
    let newSeletedAry = [];
    // 
    let oldAry = [...this.state.data];
    let newAry = oldAry.map(item=> {
      let newItem = Object.assign({}, item);
      if(item===row) {
        let index = oldSeletedAry.indexOf(item);
        if(index > -1) {
          // 存在原数组中
          oldSeletedAry.splice(index, 1);
          newSeletedAry = oldSeletedAry;
        }else {
          newSeletedAry = [...oldSeletedAry, newItem];
        }
      }
      return newItem;
    });


    this.setState({
      seletedAry: newSeletedAry,
      data: newAry,
      dataSource: this.state.dataSource.cloneWithRows(newAry)
    });
  }

新增字段

在从接口返回数据后,手动添加上一个字段来标识是否被选中。

data = data.map(item=> {
            item.isCheck = false;
            return item;
          });

然后页面会根据这个字段显示 YES 或者 NO

// 点击选中
choose(row) {
    let oldAry = [...this.state.data];
    let index = oldAry.indexOf(row);
    // 对旧数据中的值进行更新
    let newRow = Object.assign({}, row, {
        isCheck: !row.isCheck
    });
    let newAry = [
      ...oldAry.slice(0, index),
      newRow,
      ...oldAry.slice(index+1)
    ];
    this.setState({
      data: newAry,
      dataSource: this.state.dataSource.cloneWithRows(newAry)
    });
  }

判断是否在 selectedAry 数组内可以改成直接判断 row.isCheck

<Text>{row.isCheck ? 'YES' : 'NO'}</Text>

不过显示已选数量还是需要加上,不过不再使用这个数组来做判断。最终的 choose 代码:

choose(row) {
    let oldAry = [...this.state.data];
    let index = oldAry.indexOf(row);
    // 对旧数据中的值进行更新
    let newRow = Object.assign({}, row, {
        isCheck: !row.isCheck
    });
    let newAry = [
      ...oldAry.slice(0, index),
      newRow,
      ...oldAry.slice(index+1)
    ];

    let oldSelectedAry = [...this.state.seletedAry];
    let newSelectedAry = [];
    let seletedIndex = oldSelectedAry.indexOf(row);
    if(seletedIndex > -1) {
      oldSelectedAry.splice(seletedIndex, 1);
      newSelectedAry = oldSelectedAry;
    }else {
      newSelectedAry = [...oldSelectedAry, newRow];
    }
    this.setState({
      data: newAry,
      dataSource: this.state.dataSource.cloneWithRows(newAry),
      seletedAry: newSelectedAry
    });
  }

总结

数组内如果是对象,要修改对象某个属性的值,一定要使用拷贝,才能够触发_renderRow 函数,重新渲染列表。这个了解了,listView 其实就没什么难点了。这里的选中状态功能,可以拓展出修改列表中的值的功能点。

真总结

放上最终的完整代码:

'use strict';
import React, {Component} from 'react';
import {
  StyleSheet,
  View,
  ListView,
  Text,
  Image,
  // width and height of screen
  Dimensions,
  TouchableOpacity
} from 'react-native';

let {height, width} = Dimensions.get('window');
// 从豆瓣api一次获取多少条数据
let pageSize = 5;
export default class ListViewDemo extends Component {
  constructor(props) {
    super(props);
    // 初始化 state
    this.state = {
      loading: true,
      data: [],
      dataSource: new ListView.DataSource({ rowHasChanged: (r1, r2) => r1 !== r2 }),
      index: 2,
      seletedAry: []
    };
  }

  // 
  componentDidMount() {
    // 从豆瓣api获取数据,搜索react相关的书籍
    fetch('https://api.douban.com/v2/book/search?q=react&count='+pageSize)
      .then(res=> {
        if(res.status === 200) {
          // parse response
          let data = JSON.parse(res._bodyInit).books;
          this.setState({
            loading: false,
            data: data,
            dataSource: this.state.dataSource.cloneWithRows(data)
          });
        }
      })
      .catch(err=> {
        alert(JSON.stringify(err));
      })
  }

  // 单列样式
  _renderRow(row, sectionId, rowId) {
    return (
      <TouchableOpacity 
        style = {styles.item}
        onLongPress = {this.delete.bind(this, row)}
        onPress = {this.choose.bind(this, row)}
      >
        <Image
          source = {{uri: row.image}}
          style = {{width: 85, height: 120, marginRight: 20}}
          resizeMode = 'stretch'
        />
        <View style = {{flexDirection: 'column', width: width-150}}>
          <Text style = {{fontSize: 16}}>{row.title}</Text>
          <Text
            numberOfLines = {3}
            style = {{color: '#ccc'}}
          >{row.summary}</Text>
          <Text>{row.isCheck ? 'YES' : 'NO'}</Text>
        </View>
      </TouchableOpacity>
    )
  }

  loadMore() {
  // 当点击按钮时,就把底部按钮设置为 loading 状态。
    this.setState({
      loadingMore: true
    });

  // 为了实现分页,初始化 state 时加上一个属性 index,初始值为2,因为加载更多的时候就是在加载第二页了
    let start = (this.state.index-1)*pageSize;
    fetch(`https://api.douban.com/v2/book/search?q=react&start=${start}&count=${pageSize}`)
      .then(res=> {
        if(res.status === 200) {
          // parse response
          let response = JSON.parse(res._bodyInit).books;
          if(response.length === 0) {
            alert('no data response');
            return;
          }else {
            let oldAry = [...this.state.data];
            let newAry = [...oldAry, ...response];
            this.setState({
              loading: false,
              loadingMore: false,
              data: newAry,
              dataSource: this.state.dataSource.cloneWithRows(newAry),
              index: this.state.index+1
            });
          }
        }
      })
      .catch(err=> {
        alert(JSON.stringify(err));
      })
  }
  _renderFooter() {
    if(this.state.loadingMore) {
      return (
        <View
          style = {{paddingVertical: 10, justifyContent: 'center', alignItems: 'center'}}
        >
          <Text>loading...</Text>
        </View>
      )
    }
    return (
      <TouchableOpacity
        onPress = {this.loadMore.bind(this)}
        style = {{paddingVertical: 10, justifyContent: 'center', alignItems: 'center'}}
      >
        <Text>click it load more</Text>
      </TouchableOpacity>
    )
  }
  delete(row) {
    let oldAry = [...this.state.data];
    let index = oldAry.indexOf(row);
    oldAry.splice(index, 1);
    let newAry = oldAry;
    this.setState({
      data: newAry,
      dataSource: this.state.dataSource.cloneWithRows(newAry)
    });
  }
  choose(row) {
    let oldAry = [...this.state.data];
    let index = oldAry.indexOf(row);
    // 对旧数据中的值进行更新
    let newRow = Object.assign({}, row, {
        isCheck: !row.isCheck
    });
    let newAry = [
      ...oldAry.slice(0, index),
      newRow,
      ...oldAry.slice(index+1)
    ];
    let oldSelectedAry = [...this.state.seletedAry];
    let newSelectedAry = [];
    let seletedIndex = oldSelectedAry.indexOf(row);
    if(seletedIndex > -1) {
      oldSelectedAry.splice(seletedIndex, 1);
      newSelectedAry = oldSelectedAry;
    }else {
      newSelectedAry = [...oldSelectedAry, newRow];
    }
    this.setState({
      data: newAry,
      dataSource: this.state.dataSource.cloneWithRows(newAry),
      seletedAry: newSelectedAry
    });
  }
  render() {
    if(this.state.loading) {
      // if is loading
      return (
        <View style = {styles.empty}>
          <Text>loading...</Text>
        </View>
      )
    }
    return (
      <View style = {styles.container}>
        <Text>{this.state.seletedAry.length+'/'+this.state.data.length}</Text>
        <ListView
          dataSource = {this.state.dataSource}
          renderRow = {this._renderRow.bind(this)}
          initialListSize = {4}
          // hidden scroller
          showsVerticalScrollIndicator = {false}
          renderFooter = {this._renderFooter.bind(this)}
        />
      </View>
    )
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#eee'
  },
  empty: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
  },
  item: {
    paddingVertical: 10,
    paddingHorizontal: 20,
    borderBottomWidth: 1,
    borderStyle: 'solid',
    borderColor: '#ccc',
    flexDirection: 'row'
  }
});
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容