React Native 奇幻之旅(5)-內建組件 FlatList

React Native logo

這篇分享的是 FlatList 使用上常常會遇到的問題和解決方式。

The same orientation warning

VirtualizedLists should never be nested inside plain ScrollViews with the same orientation because it can break windowing and other functionality - use another VirtualizedList-backed container instead.

這個警告是在說同個組件中不能有多個相同方向的 VirtualizedLists 同時存在(如 FlatList 或 SectionList)

比如下面這個例子就會出現這個警告:

<ScrollView contentContainerStyle={styles.container}>
  <Text>O.O</Text>
  <FlatList
    data={list}
    renderItem={({ item }) => <Text>{item.title}</Text>}
    keyExtractor={({ id }) => id.toString()}
  />
  //...
</ScrollView>

解決方法

  1. 將除了 FlatList 以外的內容放在 ListHeaderComponent, ListFooterComponent
   <FlatList
       ListHeaderComponent={
          <Text>O.O</Text>
        }
       data={list}
       renderItem={({ item }) => <Text>{item.title}</Text>}
       keyExtractor={({ id }) => id.toString()}
   />

2. 用 map 取代 FlatList

  • 應該是最常見的解決方式
<ScrollView contentContainerStyle={styles.container}>
  <Text>O.O</Text>
  {list.map(({ id, title }) => (
    <Text key={id}>{title}</Text>
  ))}
  //...
 </ScrollView>

3. 雙層 ScrollView 法

  • 為了消除警告無所不用其極XD
<ScrollView contentContainerStyle={styles.container}>
   <Text>O.O</Text>
   <ScrollView horizontal={true} contentContainerStyle={{ width: '100%', height: '100%' }}>
     <FlatList
       data={list}
       renderItem={({ item }) => <Text>{item.title}</Text>}
       keyExtractor={({ id }) => id.toString()}
     />
   </ScrollView>
 </ScrollView>

4. 眼不見為淨法(x)

import { LogBox } from 'react-native';
LogBox.ignoreLogs(['VirtualizedLists should never be nested'])

Infinite scroll

若要實現滾動加載資料, 需要用到 FlatList 提供的這兩個屬性:

  • onEndReached:滾動到底部時觸發的函數
  • onEndReachedThreshold:距離底部還有多遠時觸發 onEndReached (是一個比值),設為 0.2 則表示距離內容最底部為當前列表可見長度的 20% 時觸發 onEndReached

這邊以使用 https://picsum.photos/v2/list?page=${page}&limit=${limit} API 為例,每次滾動到底部時就會調用 fetchMoreData 函數,並調用 API 獲取下一頁的數據。

import { useState } from "react"
import { StyleSheet, View, FlatList, Text, ScrollView, Image } from "react-native"

interface ImageProps {
  id: string
  author: string
  width: number
  height: number
  url: string
  download_url: string
}

export const ListPage = () => {
  const [data, setData] = useState<ImageProps[] | []>([])
  const [page, setPage] = useState(0)

  const fetchMoreData = () => {
    fetch(`https://picsum.photos/v2/list?page=${page}&limit=10`)
      .then(res => res.json())
      .then(res => {
        setData(prev => ([...prev, ...res]))
        setPage(page + 1)
      })
  }

  return (
      <View style={styles.list}>
        <FlatList
            contentContainerStyle={{ flexGrow: 1 }}
            data={data}
            ItemSeparatorComponent={() => <View style={styles.divider} />}
            renderItem={({ item }) => <Image source={{ uri: item.download_url }} style={{ width: 100, height: 100 }} />}
            keyExtractor={({ id }) => id}
            onEndReachedThreshold={0.2}
            onEndReached={fetchMoreData}
        />
      </View>
  )
}

const styles = StyleSheet.create({
  list: {
    width: 300,
    height: 200,
    backgroundColor: 'white'
  },
  divider: {
    height: 1,
    backgroundColor: 'black'
  }
})

當然這只是最基本的寫法,因為還需要考慮還有沒有剩餘的資料可以獲取,如果沒有更多的資料就不再繼續調用函數。

onEndReached 總是在 render 時自動觸發

像上面的例子設置了 onEndReachedThreshold 為 0.2,但其實 FlatList 會在剛渲染的時候就自動觸發 onEndReached

const onEndReached = () => {
  console.log('A')
}

return(
    <FlatList
      onEndReachedThreshold={0.2}
      onEndReached={onEndReached}
      {...}
    />
)

解決方法

這是因為在初始渲染時無法得知 FlatList 準確的大小和位置,所以才會誤觸 onEndReached。StackOverflow 上針對這個bug有很多討論,比較常見的解決辦法是當 distanceFromEnd > 0 的時候再去調用 onEndReached 方法:

<FlatList
  onEndReachedThreshold={0.2}
  onEndReached={({ distanceFromEnd }) => {
    if (distanceFromEnd > 0) {
      onEndReached()
    }
  }}
/>

distanceFromEnd 是用戶滾動到列表底部時,列表底部距離可見區域底部的距離。

在 FlatList 初渲染且不知道大小的情況下,distanceFromEnd 可能為 0 或者非常小的值,所以將 distanceFromEnd 設為大於 0 再去調用 onEndReached 就能夠避免在初始渲染時不小心觸發。

或者也可以把 onEndReached 替換為 onMomentumScrollEnd,在用戶停止滾動並且滾動動畫完成後才觸發 onEndReached:

<FlatList
  onEndReachedThreshold={0.2}
  onMomentumScrollEnd={onEndReached}
/>

getItemLayout 屬性

這是 FlatList 的一個屬性,常用於優化。FlatList 需要事先渲染過一次,動態獲取渲染尺寸之後再真正渲染到頁面中,如果事先知道列表中的每一項高度就能使用 getItemLayout 减少一次渲染。

const ITEM_HEIGHT = 40 // 假設每一項高度固定為 40

<FlatList
  // ...
  getItemLayout={(_, index) => (
    {
      length: ITEM_HEIGHT,
      offset: ITEM_HEIGHT * index,
      index
    }
  )}
/>

注意:如果設了 getItemLayout,那麼 renderItem 的高度必須和這個高度一樣,否則加載一段列表後就會出現空白或跑版。

性能對比

同樣的資料 render 耗時:

沒有使用 getItemLayout使用 getItemLayout
pZ6MwU2UhQ2d94
14.3ms13.7ms

這是測試的程式碼:

import { useState } from "react"
import { StyleSheet, View, FlatList, Text, ScrollView, Image } from "react-native"

interface ImageProps {
  id: string
  author: string
  width: number
  height: number
  url: string
  download_url: string
}

const ITEM_HEIGHT = 100

export const ListPage = () => {
  const [data, setData] = useState<ImageProps[] | []>([])
  const [page, setPage] = useState(0)

  const fetchMoreData = () => {
    fetch(`https://picsum.photos/v2/list?page=${page}&limit=10`)
      .then(res => res.json())
      .then(res => {
        setData(prev => ([...prev, ...res]))
        setPage(page + 1)
      })
  }

  return (
      <View style={styles.list}>
          <FlatList
            contentContainerStyle={{ flexGrow: 1 }}
            data={data}
            ItemSeparatorComponent={() => <View style={styles.divider} />}
            getItemLayout={(_, index) => (
              { length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index }
            )}
            renderItem={({ item }) => <Image source={{ uri: item.download_url }} style={{ width: 100, height: 100 }} />}
            keyExtractor={({ id }) => id}
            onEndReachedThreshold={0.2}
            onEndReached={fetchMoreData}
          />
      </View>
  )
}

const styles = StyleSheet.create({
  list: {
    width: 300,
    height: 200,
    backgroundColor: 'white'
  },
  divider: {
    height: 1,
    backgroundColor: 'black'
  }
})

優化這種簡單的資料其實效果甚微XD

留言

目前沒有留言。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *