numColumns turns a vertical FlatList into a grid. The list still virtualizes, but by rows: it groups every numColumns items into one row view, and columnWrapperStyle styles that row, which is where the horizontal gap goes. Use it instead of flexWrap on the content container, which would defeat the list's row measurement.
<FlatList data={books} numColumns={2} keyExtractor={(b) => String(b.id)}
columnWrapperStyle={styles.row} contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View style={styles.cell}>
<Image source={cover(item)} style={styles.cover} />
<Text style={styles.title} numberOfLines={1}>{item.title}</Text>
<Text style={styles.price}>${item.price.toFixed(2)}</Text>
</View>
)} />Each cell has flex: 1, so two cells share a row, and the cover style is { width: '100%', height: undefined, aspectRatio: 2 / 3 } (middle screen of the figure in Refresh and Infinite Scroll). The height: undefined matters: a require()d image gets its file's size as a default style, so without it the 400 x 600 px cover (a 1x asset, so 600 dp tall) kept that height, ignored aspectRatio, and spilled across its neighbor on the emulator. Grids need horizontal={false} and same-height cells, since a row is as tall as its tallest cell. numColumns cannot change after the first render: FlatList.js throws "Changing numColumns on the fly is not supported", so pass key={columns} as well to remount the list when a tablet rotates. With an odd count, the last cell stretches across the row; give cells a maxWidth.